[id].vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. <template>
  2. <div>
  3. <div class="inner--headers">
  4. <h2>인플루언서 프로필</h2>
  5. <div class="bread--crumbs--wrap">
  6. <span>홈</span>
  7. <span>인플루언서</span>
  8. <span>프로필</span>
  9. </div>
  10. </div>
  11. <!-- 로딩 상태 -->
  12. <div v-if="loading" class="loading-wrap">
  13. <v-progress-circular indeterminate color="primary"></v-progress-circular>
  14. <p>프로필을 불러오고 있습니다...</p>
  15. </div>
  16. <!-- 에러 상태 -->
  17. <div v-else-if="error" class="error-wrap">
  18. <v-alert type="error" dismissible @click:close="error = null">
  19. {{ error }}
  20. </v-alert>
  21. </div>
  22. <!-- 프로필 정보 -->
  23. <div v-else-if="profile" class="profile--wrap">
  24. <!-- 프로필 헤더 -->
  25. <div class="profile--header">
  26. <div class="profile--avatar">
  27. <v-img
  28. v-if="profile.PROFILE_IMAGE"
  29. :src="profile.PROFILE_IMAGE"
  30. :alt="profile.NICK_NAME + ' 프로필'"
  31. width="120"
  32. height="120"
  33. cover
  34. ></v-img>
  35. <div v-else class="no-avatar">
  36. {{ profile.NICK_NAME?.charAt(0) || "U" }}
  37. </div>
  38. </div>
  39. <div class="profile--info">
  40. <div class="profile--name">
  41. <h3>{{ profile.NICK_NAME }}</h3>
  42. <v-chip
  43. v-if="profile.PRIMARY_CATEGORY"
  44. color="primary"
  45. size="small"
  46. class="ml-2"
  47. >
  48. {{ getCategoryText(profile.PRIMARY_CATEGORY) }}
  49. </v-chip>
  50. </div>
  51. <div class="profile--meta">
  52. <div class="meta--item">
  53. <v-icon size="small">mdi-account-group</v-icon>
  54. <span>{{ formatNumber(profile.FOLLOWER_COUNT || 0) }} 팔로워</span>
  55. </div>
  56. <div class="meta--item">
  57. <v-icon size="small">mdi-chart-line</v-icon>
  58. <span>참여율 {{ profile.ENGAGEMENT_RATE || 0 }}%</span>
  59. </div>
  60. <div v-if="profile.REGION" class="meta--item">
  61. <v-icon size="small">mdi-map-marker</v-icon>
  62. <span>{{ profile.REGION }}</span>
  63. </div>
  64. </div>
  65. <p v-if="profile.DESCRIPTION" class="profile--description">
  66. {{ profile.DESCRIPTION }}
  67. </p>
  68. </div>
  69. </div>
  70. <!-- SNS 채널 -->
  71. <div v-if="snsChannels.length > 0" class="profile--channels">
  72. <h4>SNS 채널</h4>
  73. <div class="channels--grid">
  74. <a
  75. v-for="channel in snsChannels"
  76. :key="channel.platform"
  77. :href="getSnsUrl(channel)"
  78. target="_blank"
  79. rel="noopener noreferrer"
  80. class="channel--card"
  81. >
  82. <v-icon size="24" :color="getSnsColor(channel.platform)">
  83. {{ getSnsIcon(channel.platform) }}
  84. </v-icon>
  85. <div class="channel--info">
  86. <h5>{{ getSnsTitle(channel.platform) }}</h5>
  87. <p>{{ channel.handle }}</p>
  88. </div>
  89. <v-icon size="16">mdi-open-in-new</v-icon>
  90. </a>
  91. </div>
  92. </div>
  93. <!-- 콘텐츠 통계 -->
  94. <div class="profile--stats">
  95. <h4>콘텐츠 통계</h4>
  96. <div class="stats--grid">
  97. <div class="stat--card">
  98. <div class="stat--icon followers">
  99. <v-icon>mdi-account-group</v-icon>
  100. </div>
  101. <div class="stat--content">
  102. <h5>팔로워</h5>
  103. <p>{{ formatNumber(profile.FOLLOWER_COUNT || 0) }}</p>
  104. </div>
  105. </div>
  106. <div class="stat--card">
  107. <div class="stat--icon engagement">
  108. <v-icon>mdi-chart-line</v-icon>
  109. </div>
  110. <div class="stat--content">
  111. <h5>참여율</h5>
  112. <p>{{ profile.ENGAGEMENT_RATE || 0 }}%</p>
  113. </div>
  114. </div>
  115. <div class="stat--card">
  116. <div class="stat--icon partnerships">
  117. <v-icon>mdi-handshake</v-icon>
  118. </div>
  119. <div class="stat--content">
  120. <h5>협업</h5>
  121. <p>{{ formatNumber(partnershipCount || 0) }}건</p>
  122. </div>
  123. </div>
  124. </div>
  125. </div>
  126. <!-- 협업 이력 -->
  127. <div v-if="partnerships.length > 0" class="profile--partnerships">
  128. <h4>협업 이력</h4>
  129. <div class="partnerships--timeline">
  130. <div
  131. v-for="partnership in partnerships"
  132. :key="partnership.SEQ"
  133. class="timeline--item"
  134. >
  135. <div class="timeline--date">
  136. {{ formatDate(partnership.REG_DATE) }}
  137. </div>
  138. <div class="timeline--content">
  139. <h5>{{ partnership.vendorName }}</h5>
  140. <p v-if="partnership.DESCRIPTION">
  141. {{ partnership.DESCRIPTION }}
  142. </p>
  143. <div class="timeline--meta">
  144. <v-chip size="x-small" :color="getStatusColor(partnership.STATUS)">
  145. {{ getStatusText(partnership.STATUS) }}
  146. </v-chip>
  147. </div>
  148. </div>
  149. </div>
  150. </div>
  151. </div>
  152. </div>
  153. <!-- 데이터 없음 -->
  154. <div v-else class="no-data-wrap">
  155. <div class="no-data">
  156. <v-icon size="64" color="grey-lighten-1">mdi-account-question</v-icon>
  157. <h3>프로필을 찾을 수 없습니다</h3>
  158. <p>요청하신 인플루언서 프로필이 존재하지 않습니다</p>
  159. </div>
  160. </div>
  161. </div>
  162. </template>
  163. <script setup>
  164. import { ref, onMounted } from "vue";
  165. import { useRoute } from "vue-router";
  166. /************************************************************************
  167. | 레이아웃
  168. ************************************************************************/
  169. definePageMeta({
  170. layout: "default",
  171. });
  172. /************************************************************************
  173. | 스토어 & 라우터
  174. ************************************************************************/
  175. const route = useRoute();
  176. const { $toast } = useNuxtApp();
  177. /************************************************************************
  178. | 반응형 데이터
  179. ************************************************************************/
  180. const loading = ref(false);
  181. const error = ref(null);
  182. const profile = ref(null);
  183. const partnerships = ref([]);
  184. const partnershipCount = ref(0);
  185. /************************************************************************
  186. | computed
  187. ************************************************************************/
  188. const snsChannels = computed(() => {
  189. if (!profile.value?.SNS_CHANNELS) return [];
  190. try {
  191. return JSON.parse(profile.value.SNS_CHANNELS);
  192. } catch (e) {
  193. return [];
  194. }
  195. });
  196. /************************************************************************
  197. | 메서드
  198. ************************************************************************/
  199. const loadProfile = async () => {
  200. try {
  201. loading.value = true;
  202. error.value = null;
  203. const influencerSeq = route.params.id;
  204. const params = { influencerSeq };
  205. useAxios()
  206. .post("/api/influencer/profile", params)
  207. .then((res) => {
  208. if (res.data.success) {
  209. profile.value = res.data.data.profile;
  210. partnerships.value = res.data.data.partnerships || [];
  211. partnershipCount.value = res.data.data.partnershipCount || 0;
  212. } else {
  213. error.value = res.data.message || "프로필을 불러오는데 실패했습니다.";
  214. }
  215. })
  216. .catch((err) => {
  217. error.value = err.message || "프로필을 불러오는데 실패했습니다.";
  218. })
  219. .finally(() => {
  220. loading.value = false;
  221. });
  222. } catch (err) {
  223. error.value = err.message || "프로필을 불러오는데 실패했습니다.";
  224. loading.value = false;
  225. }
  226. };
  227. const getCategoryText = (category) => {
  228. const categoryMap = {
  229. FASHION_BEAUTY: "패션·뷰티",
  230. FOOD_HEALTH: "식품·건강",
  231. LIFESTYLE: "라이프스타일",
  232. TECH_ELECTRONICS: "테크·가전",
  233. SPORTS_LEISURE: "스포츠·레저",
  234. CULTURE_ENTERTAINMENT: "문화·엔터테인먼트",
  235. };
  236. return categoryMap[category] || category || "기타";
  237. };
  238. const formatNumber = (num) => {
  239. if (!num) return "0";
  240. if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
  241. if (num >= 1000) return (num / 1000).toFixed(1) + "K";
  242. return num.toString();
  243. };
  244. const formatDate = (dateString) => {
  245. return new Date(dateString).toLocaleDateString("ko-KR");
  246. };
  247. const getSnsIcon = (platform) => {
  248. const iconMap = {
  249. instagram: "mdi-instagram",
  250. youtube: "mdi-youtube",
  251. tiktok: "mdi-music-note",
  252. blog: "mdi-post",
  253. facebook: "mdi-facebook",
  254. twitter: "mdi-twitter",
  255. };
  256. return iconMap[platform.toLowerCase()] || "mdi-link";
  257. };
  258. const getSnsColor = (platform) => {
  259. const colorMap = {
  260. instagram: "#E4405F",
  261. youtube: "#FF0000",
  262. tiktok: "#000000",
  263. blog: "#00B336",
  264. facebook: "#1877F2",
  265. twitter: "#1DA1F2",
  266. };
  267. return colorMap[platform.toLowerCase()] || "#666666";
  268. };
  269. const getSnsTitle = (platform) => {
  270. const titleMap = {
  271. instagram: "Instagram",
  272. youtube: "YouTube",
  273. tiktok: "TikTok",
  274. blog: "Blog",
  275. facebook: "Facebook",
  276. twitter: "Twitter",
  277. };
  278. return titleMap[platform] || platform;
  279. };
  280. const getSnsUrl = (channel) => {
  281. const handle = channel.handle.replace("@", "");
  282. const urlMap = {
  283. instagram: `https://instagram.com/${handle}`,
  284. youtube: `https://youtube.com/@${handle}`,
  285. tiktok: `https://tiktok.com/@${handle}`,
  286. blog: channel.handle.startsWith("http") ? channel.handle : `https://${handle}`,
  287. facebook: `https://facebook.com/${handle}`,
  288. twitter: `https://twitter.com/${handle}`,
  289. };
  290. return urlMap[channel.platform.toLowerCase()] || channel.handle;
  291. };
  292. const getStatusText = (status) => {
  293. const statusMap = {
  294. PENDING: "진행중",
  295. APPROVED: "완료",
  296. REJECTED: "거절됨",
  297. CANCELLED: "취소됨",
  298. };
  299. return statusMap[status] || status || "알 수 없음";
  300. };
  301. const getStatusColor = (status) => {
  302. const colorMap = {
  303. PENDING: "warning",
  304. APPROVED: "success",
  305. REJECTED: "error",
  306. CANCELLED: "grey",
  307. };
  308. return colorMap[status] || "grey";
  309. };
  310. /************************************************************************
  311. | 라이프사이클
  312. ************************************************************************/
  313. onMounted(() => {
  314. loadProfile();
  315. });
  316. </script>
  317. <style scoped>
  318. .profile--wrap {
  319. background: white;
  320. border-radius: 12px;
  321. padding: 24px;
  322. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  323. }
  324. .profile--header {
  325. display: flex;
  326. gap: 24px;
  327. margin-bottom: 32px;
  328. }
  329. .profile--avatar {
  330. width: 120px;
  331. height: 120px;
  332. border-radius: 60px;
  333. overflow: hidden;
  334. flex-shrink: 0;
  335. background: #f5f5f5;
  336. display: flex;
  337. align-items: center;
  338. justify-content: center;
  339. }
  340. .no-avatar {
  341. font-size: 48px;
  342. font-weight: bold;
  343. color: #666;
  344. }
  345. .profile--info {
  346. flex: 1;
  347. }
  348. .profile--name {
  349. display: flex;
  350. align-items: center;
  351. margin-bottom: 12px;
  352. }
  353. .profile--name h3 {
  354. margin: 0;
  355. font-size: 24px;
  356. font-weight: 600;
  357. }
  358. .profile--meta {
  359. display: flex;
  360. flex-wrap: wrap;
  361. gap: 16px;
  362. margin-bottom: 16px;
  363. }
  364. .meta--item {
  365. display: flex;
  366. align-items: center;
  367. gap: 6px;
  368. color: #666;
  369. }
  370. .profile--description {
  371. font-size: 14px;
  372. line-height: 1.6;
  373. color: #444;
  374. margin: 0;
  375. }
  376. .profile--channels {
  377. margin-top: 32px;
  378. }
  379. .profile--channels h4,
  380. .profile--stats h4,
  381. .profile--partnerships h4 {
  382. margin: 0 0 16px 0;
  383. font-size: 18px;
  384. font-weight: 600;
  385. color: #333;
  386. }
  387. .channels--grid {
  388. display: grid;
  389. grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  390. gap: 16px;
  391. }
  392. .channel--card {
  393. display: flex;
  394. align-items: center;
  395. gap: 12px;
  396. padding: 16px;
  397. background: #f8f9fa;
  398. border-radius: 8px;
  399. text-decoration: none;
  400. color: inherit;
  401. transition: transform 0.2s, box-shadow 0.2s;
  402. }
  403. .channel--card:hover {
  404. transform: translateY(-2px);
  405. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  406. }
  407. .channel--info {
  408. flex: 1;
  409. }
  410. .channel--info h5 {
  411. margin: 0 0 4px 0;
  412. font-size: 14px;
  413. font-weight: 600;
  414. }
  415. .channel--info p {
  416. margin: 0;
  417. font-size: 13px;
  418. color: #666;
  419. }
  420. .profile--stats {
  421. margin-top: 32px;
  422. }
  423. .stats--grid {
  424. display: grid;
  425. grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  426. gap: 16px;
  427. }
  428. .stat--card {
  429. display: flex;
  430. align-items: center;
  431. gap: 16px;
  432. padding: 20px;
  433. background: #f8f9fa;
  434. border-radius: 8px;
  435. }
  436. .stat--icon {
  437. width: 48px;
  438. height: 48px;
  439. border-radius: 24px;
  440. display: flex;
  441. align-items: center;
  442. justify-content: center;
  443. color: white;
  444. }
  445. .stat--icon.followers {
  446. background: #2196f3;
  447. }
  448. .stat--icon.engagement {
  449. background: #4caf50;
  450. }
  451. .stat--icon.partnerships {
  452. background: #ff9800;
  453. }
  454. .stat--content h5 {
  455. margin: 0 0 4px 0;
  456. font-size: 14px;
  457. color: #666;
  458. }
  459. .stat--content p {
  460. margin: 0;
  461. font-size: 20px;
  462. font-weight: 600;
  463. color: #333;
  464. }
  465. .profile--partnerships {
  466. margin-top: 32px;
  467. }
  468. .partnerships--timeline {
  469. display: flex;
  470. flex-direction: column;
  471. gap: 16px;
  472. }
  473. .timeline--item {
  474. display: flex;
  475. gap: 16px;
  476. }
  477. .timeline--date {
  478. flex-shrink: 0;
  479. width: 100px;
  480. font-size: 14px;
  481. color: #666;
  482. }
  483. .timeline--content {
  484. flex: 1;
  485. background: #f8f9fa;
  486. padding: 16px;
  487. border-radius: 8px;
  488. position: relative;
  489. }
  490. .timeline--content::before {
  491. content: "";
  492. position: absolute;
  493. left: -8px;
  494. top: 50%;
  495. transform: translateY(-50%);
  496. width: 16px;
  497. height: 16px;
  498. background: #f8f9fa;
  499. transform: rotate(45deg);
  500. }
  501. .timeline--content h5 {
  502. margin: 0 0 8px 0;
  503. font-size: 16px;
  504. font-weight: 600;
  505. }
  506. .timeline--content p {
  507. margin: 0 0 8px 0;
  508. font-size: 14px;
  509. color: #666;
  510. }
  511. .timeline--meta {
  512. display: flex;
  513. gap: 8px;
  514. }
  515. .loading-wrap,
  516. .error-wrap,
  517. .no-data-wrap {
  518. display: flex;
  519. flex-direction: column;
  520. align-items: center;
  521. justify-content: center;
  522. padding: 60px 20px;
  523. }
  524. .no-data {
  525. text-align: center;
  526. }
  527. .no-data h3 {
  528. margin: 16px 0 8px;
  529. color: #666;
  530. }
  531. .no-data p {
  532. color: #999;
  533. }
  534. @media (max-width: 768px) {
  535. .profile--header {
  536. flex-direction: column;
  537. align-items: center;
  538. text-align: center;
  539. }
  540. .profile--meta {
  541. justify-content: center;
  542. }
  543. .timeline--item {
  544. flex-direction: column;
  545. gap: 8px;
  546. }
  547. .timeline--date {
  548. width: auto;
  549. }
  550. .timeline--content::before {
  551. display: none;
  552. }
  553. }
  554. </style>